Recoil が面白いので Redux との違いを説明してみる
https://gyazo.com/a668443605402c6477e3462183fda8bd
前置き(私見含む)
React でそれなりの規模のアプリケーションを作ったことのある方なら、状態管理の辛さをよく知っていると思う
コンポーネントを跨いだ変数をひとつ作ろうと思っただけなのに「まずは Flux アーキテクチャのコンセプトとアンチパターンから学ぶ必要があります。大量の props バケツリレーから逃れるためには〜」とか言われても
現実的で複雑なアプリケーションの状態、つまり「非同期処理」や「状態同士の依存関係」……などを作っていくのは大変
そんな中 Facebook が新たな状態管理ライブラリをリリースした、それが Recoil これは Redux とも ReactN とも全く異なるアプローチのライブラリで、しかも圧倒的に分かりやすい teramotodaiki.icon 現在は experimental(実験段階) なので Redux のコードをごっそり書き直すにはまだ早いが、いずれそうすべき時が来ると直感した teramotodaiki.icon
Recoil のアプローチを一言でいうなら、アプリケーションの状態を有向グラフで表現するということ
(細かいことを言えば Hooks ネイティブな API や Suspense を前提とした Promise の利用、次世代 React のバッチ描画への対応……などの特徴もある。)
「アプリケーションの状態を有向グラフで表現する」とはどういうことか? Redux とはどう違うのか? ということをまとめてみたくなった なお執筆開始時点の Recoil のバージョンは 0.0.10 なので、サンプルコードなどは今後変わる可能性が高い。
蛇足
Recoil と Redux を比較して優劣をつけるつもりはない、それは無意味だから teramotodaiki.icon
今回は Recoil "のアプローチ" を理解するために Redux "のアプローチ" と比較した
両者のライブラリはそもそも対象のターゲットが異なる
Redux は React に依存していないが、 Recoil は React に(そして執筆時点では ReactDOM にも)依存している
Redux にはエコシステム(middleware がたくさん公開されているという意味)がある
アプリケーションの状態 (State) をどのように表すか
前置き
状態 (State) とは、例えば「現在のログインユーザーは、 A さんです」とか「今見ているページは、トップページです」といった情報のことを指す
アプリケーションには様々な State があり、絶えず変化し続けている
アプリケーションの規模が大きくなると State 全体を見渡すことが難しくなっていくため、分割する必要がある
分割方法に注目してみると、 Recoil のアプローチがよく分かる
Redux における State: ひとつの大きなプレーンオブジェクト
State はひとつのプレーンオブジェクトでなければならない(という制約がある)
State は、直前の State と任意の payload から State を返す副作用のない関数 (=Reducer) として定義される
状態の種類は予め定義しておけるが、値は絶えず変化し続けるため、多くの場合キーバリューペアの形になる
{ ユーザー全体: { ユーザーID: ユーザー情報 } } のように、キーバリューペアが入れ子になることもある
アプリケーションの規模が大きくなる場合は State を更新する単位 (=Reducer) を分割する
Reducer をどのくらいの大きさまで分割するかは開発者に委ねられている
感覚としては、一万行の Reducer は「大きすぎる」し、十行の Reducer が千個あれば「数が多すぎる」ので、その間のどこかになる
Reducer 同士は互いに独立しているので、依存関係にある状態(後述)は同じ Reducer にある方が都合が良い
State が更新される時は、全体がいっぺんに更新される
常に全体の State をひとつのプレーンオブジェクトに書き出せる (export できる)
Recoil における State: たくさんの小さな Atom Atom または Selector を使って状態を定義していく
例:現在のログインユーザー
ひとつの Selector は、他の Atom または Selector の値から計算可能な、ひとつの状態を表す 例:現在のログインユーザーのユーザーアイコン (これは現在のログインユーザーから計算できる)
これは読み取り専用の Selector (ReadOnlySelector) の説明だが、書き込み可能 Selector (ReadWriteSelector) もある
この文章では、単に Selector と呼ぶ場合は ReadOnlySelector のことを指している
(余談)ReadWriteSelector は propertyDescriptor のように getter/setter で定義できるが、 setter はせっかくの有向グラフを逆向きに伝播できてしまい非常にややこしいので、個人的には廃止するか名称変更して欲しい 本当に必要なケースでのみ使うことを強く推奨する teramotodaiki.icon
値をノード、依存関係をエッジとみなせば、これは有向グラフとみなせる
Atom は1つのノードを作る
Selector は任意本のエッジと1つのノードを作る
アプリケーション内には複数の有向グラフが存在しうる。いくつ存在するか意識する必要はない
State をどのように更新するか
前置き
State は、ユーザーの操作、時間の経過、サーバーからの応答……などに応じて、絶えず更新され続ける
どのライブラリでも、更新するための方法が厳格に定められている
State の値は、直前の State の値と任意の payload から一意に計算される。この時の payload を Redux では Action と呼ぶ
Action は何でも良いが、参照等価なオブジェクトであることが望ましい
参照等価というのは、例えば「ユーザーは A さんです」「あ、やっぱりユーザーは B さんになりました」という場合、 Action の中身を書き換えるのではなく Action を二回 Dispatch せよ、という意味
Action を Redux に与えて状態を更新させることを Redux では Dispatch と呼ぶ
Dispatch はどこからでも出来るので、例えば「ユーザーがボタン A を押した時、 Action A を Dispatch する」という風にも使える
Dispatch は同期処理 (直ちに状態が更新され、変更が通知されることが保証されている) である
パフォーマンス向上のために複数の Action をプールしてまとめて更新する「バッチ処理」にする middleware もある
例えば、数値を 1 増やす Action があったとき、どんな順番であれ、これを N 回 Dispatch すれば、数値は N 増えることが保証される
Redux の公式ドキュメントによれば、 Reducer は Pure でなければならない
Pure の意味は「冪等」に近い。副作用がなく、引数が同じであれば同じ結果を返すという意味
Recoil における状態の更新: Atom を更新する → Selector が更新される
Atom は React Hooks を介してどこからでも任意の値に変更できる
Atom の値が変更されると、その Atom に依存している Selector の値が自動的に更新される
Selector の値が変更されると、その Selector に依存している別の Selector の値が自動的に更新される
……以下略
実行時に循環参照をチェックする仕組みがあるので無限ループはしない
変更後の値 Value を直接与えてもいいし、変更前の値を受け取って変更後の値を返す関数 Value => Value を与えてもいい
このとき、 Value => Value の関数は Pure であることが望ましい
依存関係のある State をどのように取得するか
前置き
State A の値が決まると State B の値が決まるとき、「State A と State B の間には依存関係がある」と呼ぶことにする
例:ユーザーが画像を投稿するアプリケーション
A さんがログインした。このアプリケーションのマイページでは、ユーザーが投稿した画像と、そこに寄せられたコメントを閲覧できる
まずはユーザー A を取得して、それからユーザー A が自ら投稿した画像 P(A) を取得する
さらに、画像 P(A) ごとのコメント C(P(A)) を取得する
さらに、コメントごとのコメント主のユーザー U(C(P(A))) を取得する
……満足した A さんは、今度は B さんのマイページへ移動した。まずはユーザー B を取得して……(以下略)
Redux における依存関係の更新: あくまで状態管理のみ。取得はスコープ外
スコープ外というのは、Redux というライブラリがカバーしている領域ではないという意味
様々な middleware が (主に非同期における) 依存関係の複雑さをカバーしている
これ以上は middleware によってアプローチが全く異なるのでここでは省く
Recoil における依存関係の更新: 値が必要になった時に取得される
Selector はひとつの get 関数として定義される。 get 関数の戻り値が Selector の値となる
React Hooks を使うと、Selector から値を取り出すことができる。これが「値が必要になったとき」
Selector の get 関数は、値が必要になったときに一度実行され、それ以降はメモ化される
これが重要な性質 teramotodaiki.icon
get 関数の中で使える get ヘルパー (ややこしい) で、他の Atom または Selector の値を利用できる
get ヘルパーを使うことで、どの Atom または Selector に依存しているのかを Recoil に伝えることができる
get ヘルパーは get というより subscribe なのである teramotodaiki.icon
State が変更されたことをどうやって View に伝えるか
前置き
View とは、ユーザーインターフェース、ここでは特に React Component のことを指す
例えば、「ログイン中のユーザーのユーザーアイコンを表示する」には、現在のログインユーザーに応じて View を更新する必要がある
これをライブラリ側から見ると、 State が変化するたびに、影響を受ける View をすべて更新させる必要がある
逆にいうと、影響を受けない View はなるべく更新させないことが望ましい(パフォーマンスのため)
Redux は特定のライブラリ向けの機能を持っていないので、公式の React binding である react-redux と比較する react-redux における View の更新: 必要な State を取り出して、変化があれば更新する
State の更新は Reducer の仕事なので、 Redux は「どの State が更新されたのか」を知らない
いかなるときも State 全体がいっぺんに更新されるので、Redux はあらゆる更新を通知する
あらゆる更新の度に View を更新するのはあまりにも効率が悪いので、必要な時だけ更新する機能が必要
react-redux では特定の State が変更された時にだけ React Component を再描画するために「セレクター関数」を用いる
react-redux のセレクター関数は Recoil の Selector とは意味が違うので注意
code:useSelector.tsx
import { useSelector } from 'react-redux';
function Component () {
const value = useSelector(state => state.value);
return <div>{value}</div>;
}
useSelector の中に書いてあるのがセレクター関数
セレクター関数は、 Redux のあらゆる更新の度に実行されるので、 pure でなければならない
上記の例で言うと、 prevState.value !== nextState.value を計算してみて、 true であれば更新する
デフォルトでは参照で比較されるが、比較方法はオプションで変更することもできる
下のコードは一見すると上のコードと等価に見えるが、前述の理由からパフォーマンス的に問題のある実装と言える
code:useSelector.tsx
import { useSelector } from 'react-redux';
function Component () {
const { value } = useSelector(state => state);
return <div>{value}</div>;
}
Recoil における View の更新: 必要な Atom/Selector のみ Subscribe する
React Hooks を使って Atom (Selector) の値を取り出すと、値が更新された時に React Component を再描画してくれる
code:useRecoilValue.tsx
import { atom, useRecoilValue } from 'recoil';
const hoge = atom({
key: 'hoge',
value: 1
});
function Component () {
const value = useRecoilValue(hoge);
return <div>{value}</div>;
}
セレクタ式がないので、余計な更新を防ぐためにも Atom や Selector は十分に小さくすべきである
直前の値との比較もされないので、厳密には「更新があるかも知れない時に」と言うべきかも知れない
例えば hoge を 1 から 1 に更新しても (これは更新と呼ぶべきでないが) 、 React Component は再描画される
非同期処理をどのように扱うか
前置き
処理が別スレッドで行われる処理を非同期処理と呼ぶ
例:Webサーバから fetch してデータを取得する場合
ある State が非同期処理によって取得される場合、一般的に次の3つの状態で表すことが多い
まだ値が確定していない状態
値が確定している状態
何らかのエラーで取得できなかった状態
これらの「状態」と「値」はセットで変更する必要がある
つまり、状態だけが先に「確定状態」に変更されて値が変更されていない、という状況は避けなければならない
Redux における非同期処理: Action を3〜4個に分けて Dispatch する
Recoil における非同期処理: Selector を非同期にする
Selector の get 関数は、値が必要になったときに一度実行され、それ以降はメモ化される
可変長のコンポーネントに対してどのように binding するか
前置き
例えば、 あなたが画面上にポスト・イットを自由に配置できるアプリケーションを開発しているとする
今、ある人は全部で 1000 個のカードを配置しており、これが全て React Component として描画されている
ある人がカードを右に 1px だけ動かした。このとき、なるべく再描画コストを抑えるには、どうすれば良いか?
何も意識せずに実装すれば 1000 個のコンポーネントが再描画されてしまう
カーソルの移動は 60fps 以上の分解能で発生するため、頻繁に更新されてしまい、カクカクした動きになる
「そんなことは気にしたことがない」という方は、この章を読み飛ばしても問題ない
追記:これは実装次第でほとんど差がないことがわかった。ただしアプローチが異なることは記しておきたい #TODO コンポーネントのメモ化はどちらの場合でも有効である
メモ化しない場合、id と data を別々に保持するべきであり、その場合は Write の方法に気を使う必要がある
react-redux における可変長コンポーネントの binding: コンポーネントをメモ化する
まずは何も考えずに実装をしてみる
code:many.tsx
上記の例では 1000 個のコンポーネントが再描画されてしまう。さてどうすべきか
code:many.tsx
これで完了済みにされたコンポーネントだけが再描画されるようになった
ちなみに〜(Zombie Children の話)
Recoil における可変長コンポーネントの binding: Atom にパラメータを与える
code:atomFamily.tsx
内部的には atom({ key: '元の key ' + stringify(parameter) }) のような実装となっている
内部的には 1000 個の Atom が作られている
そもそも Atom が独立しているから、独立して Subscribe できる
非同期エラーをどのようにユーザーに提示するか
前置き
ダウンロードなどの非同期処理はしばしば失敗する
ものによってはエラーの原因をユーザーに提示した方が良いケースがある(例:端末がオフラインです!)
場合によっては、リトライなどのアクションを促す必要があるかも知れない
これを React で表示するには、エラーを一旦保持する必要がある
Redux におけるエラー提示:エラー格納用の Reducer に値を保持する
最新のエラーを1件提示したいだけなら、{ error: Error } というシンプルな Reducer を定義すれば良い
非同期処理の失敗時など、必要な場所で Action を dispatch すれば良い
しかし、一般的にエラー "提示" は様々なメタデータを伴う
e.g. severity: 'success' | 'info' | 'warning' | 'error' (Material UI): ポップアップの色やアイコン
e.g. 分かりやすい日本語のメッセージ。例えば「この情報を変更する権限がありません」など
これらのメタデータを Error オブジェクトひとつで表現することは難しいので、現実的にはアプリケーション特有のエラー提示インターフェースを定義することになる
このような「アプリケーション特有のエラー提示」を、どこに書くか? という問題がある
例えば、 FETCH_DATA_FAILED Action と "データを取得できませんでした" という文字列とをいかに対応づけるか?
あるいは、 <Button onClick={retry}>リトライ</Button> のようなボタンをどのように表現するか?
上記のような表現的な部分は Reducer に書くよりも Component に書いた方が都合が良いことがある
しかし、 Redux の都合上、一度は Reducer の State (つまりプレーンオブジェクト)に変換する必要がある
Recoil におけるエラー提示: Loadable から Error を取得する
典型的な Loadable のエラーを表示したいだけなら、専用の Atom を作る必要はない
Atom または Selector の非同期処理が失敗した場合、 Loadable の state が hasError になるので、これを Hook する
code:
デメリットとしては、atomFamily または selectorFamily の Loadable をすべて取得する方法がない
すべての ID が配列で与えられる場合、ID ごとに Component を作ることで、ひとつずつ取得できる
この方法だとコードはかなり冗長になる
View にもよるが、そのデータが本来表示されるべき部分に、エラーも表示する仕組みを用意した方が良さそう
メモ
Redux における Stale props and Zombie children
アプリケーションの規模が大きくなると、 Action が数十〜数百個になることもある
ボイラープレートを減らすために、 Action を簡単に作れるユーティリティがサードパーティから提供されている
関連性の高い Action と Reducer をひとつのファイルにまとめる方式が提案されている
Redux の State を更新するには、 Action を Dispatch するしかない
Action は何でも良いが、参照等価なプレーンオブジェクトが推奨されている
例えば「ユーザー X の投稿した画像 P(X) をセットする」のような動作を、 { type: 'SET_USERS_PICTURES', userId: 1, pictures: [{...}] } というアクションで表現する
複数の状態を同時に更新したい場合は、ひとつの Action に対して複数の状態を更新するように Reducer を設計する
Action はアトミックな更新の単位であると言える